import pyinotify
import os

class _Flags(object):
    pass
_flags = _Flags()
_flags.__dict__.update(pyinotify.EventsCodes.ALL_FLAGS)

class _WatchedDirectory(object):
    @classmethod
    def add_file(cls, file_path, file_change_callback):
        assert(os.path.isabs(file_path))
        real_file_path = os.path.realpath(file_path)
        dir_path, name = os.path.split(real_file_path)
        watched_directory = cls.get_directory(dir_path)
        callback = lambda: file_change_callback(file_path)

        if os.path.exists(real_file_path):
            watched_directory.watched_file_callbacks[name] = callback
        else:
            watched_directory.missing_file_callbacks[name] = callback

    @classmethod
    def stop_watching_file(cls, file_path):
        dir_path, name = os.path.split(os.path.realpath(file_path))
        cls.get_directory(dir_path).remove_file_watch(name)

    # The following should not be accessed by users of this class:

    watched_directories = {}
    @classmethod
    def get_directory(cls, dir_path):
        return (cls.watched_directories.get(dir_path) or
            _WatchedDirectory(dir_path))

    def __init__(self, path):
        self.path = path
        self.watch_descriptor = None

        # These dictionaries map from names to callbacks that take no
        # parameters.
        self.watched_file_callbacks = {}
        self.missing_file_callbacks = {}

        # This dictionary maps from names to _WatchedDirectory objects.
        self.missing_subdirs = {}

        self.watched_directories[self.path] = self
        if os.path.exists(self.path):
            self.start_watching()
        else:
            self.mark_directory_as_missing()

    def mark_directory_as_missing(self):
        assert(not os.path.exists(self.path))

        # Make sure that parent directories are waiting for this one to
        # be created.
        parent_dir_path, name = os.path.split(self.path)
        self.get_directory(parent_dir_path).missing_subdirs[name] = self

    def remove_file_watch(self, name):
        assert(name not in self.missing_subdirs)
        if name in self.watched_file_callbacks:
            del self.watched_file_callbacks[name]
        elif name in self.missing_file_callbacks:
            del self.missing_file_callbacks[name]

        if any((self.watched_file_callbacks, self.missing_file_callbacks,
                self.missing_subdirs)):
            return

        # There is no longer a reason to monitor this directory.
        if os.path.exists(self.path):
            self.stop_watching()
        else:
            parent_dir_path, name = os.path.split(self.path)
            del self.watched_directories[parent_dir_path].missing_subdirs[name]
        del self.watched_directories[self.path]

    def start_watching(self):
        self.watch_descriptor = _watch_manager.add_watch(
            self.path,
            _flags.IN_CREATE | _flags.IN_MOVED_TO |
            _flags.IN_DELETE | _flags.IN_MOVED_FROM |
            _flags.IN_CLOSE_WRITE | _flags.IN_DELETE_SELF,
            self.on_dir_change).values()[0]
        assert(self.watch_descriptor is not None)

        # It might be possible that one of the files/subdirectories we're
        # interested in was created before we started watching the directory,
        # so check for them now.
        for name in (self.missing_file_callbacks.keys() +
                self.missing_subdirs.keys()):
            file_path = os.path.join(self.path, name)
            if os.path.exists(file_path):
                was_missing = name in self.missing_file_callbacks
                self.on_file_or_subdir_created(name, was_moved_to=False)

                # If this is a file and the size is non-zero, we missed the
                # write event and possibly the file close event.  In that case,
                # pretend that the file was closed for writing.
                # TODO: To avoid these types of race conditions, change
                #       everything to monitor directories recursively.
                if (was_missing and not os.path.isdir(file_path) and
                        os.path.getsize(file_path) != 0):
                    self.on_file_changed(name)

    def on_dir_change(self, event):
        if event.mask & _flags.IN_DELETE_SELF:
            return self.on_self_deleted()

        name = self.subname_from_event(event)
        was_moved_to = (event.mask & _flags.IN_MOVED_TO) != 0
        if event.mask & (_flags.IN_CREATE | _flags.IN_MOVED_TO):
            return self.on_file_or_subdir_created(name, was_moved_to)
        if event.mask & (_flags.IN_DELETE | _flags.IN_MOVED_FROM):
            return self.on_file_or_subdir_deleted(name)
        if event.mask & _flags.IN_CLOSE_WRITE:
            return self.on_file_changed(name)

    def subname_from_event(self, event):
        assert(event.pathname.startswith(event.path))
        return event.pathname[len(event.path) + 1:]

    def stop_watching(self):
        if self.watch_descriptor is not None:
            _watch_manager.rm_watch(self.watch_descriptor)
            self.watch_descriptor = None

    def on_file_or_subdir_created(self, name, was_moved_to):
        # If this is a directory and we're waiting for files to be created
        # under it, start watching it.
        is_dir = os.path.isdir(os.path.join(self.path, name))
        if (name in self.missing_subdirs and is_dir):
            missing_subdir = self.missing_subdirs[name]
            del self.missing_subdirs[name]
            missing_subdir.start_watching()

        if name not in self.missing_file_callbacks:
            return

        # Move the callback into the set of watched file callbacks.
        callback = self.missing_file_callbacks[name]
        del self.missing_file_callbacks[name]
        self.watched_file_callbacks[name] = callback

        # Don't invoke the callback until the newly created file is closed
        # after writing to it, unless this was a move operation from an
        # existing file or it's a directory creation.
        if was_moved_to or is_dir:
            callback()

    def on_file_changed(self, name):
        callback = self.watched_file_callbacks.get(name)
        if callback:
            callback()

    def on_file_or_subdir_deleted(self, name):
        if name not in self.watched_file_callbacks:
            return

        callback = self.watched_file_callbacks[name]
        del self.watched_file_callbacks[name]
        self.missing_file_callbacks[name] = callback
        callback()

    def on_self_deleted(self):
        # We don't remove the watch since inotify doesn't want us to call
        # rm_watch on files/directories that have been deleted.
        self.watch_descriptor = None
        self.mark_directory_as_missing()

def start():
    global _watch_manager
    _watch_manager = pyinotify.WatchManager()

    # Start the thread that will wait for files to change.
    notifier = pyinotify.ThreadedNotifier(_watch_manager, None)
    notifier.daemon = True
    notifier.start()

def add_file(file_path, mod_time, file_change_callback):
    """Call the callback when the contents of the file at the given absolute
    file path change or the file is deleted.  You can call this function on
    a file that does not exist, and the callback will be called when the file
    is closed for writing after it is created.

    mod_time is the file's current modification time.
    """
    # Note that mod_time is unused, but it is necessary for the Windows
    # implementation.
    _WatchedDirectory.add_file(file_path, file_change_callback)

def remove_file(file_path):
    """Remove a previous file being monitored for modifications, deletion,
    or creation.
    """
    assert(os.path.isabs(file_path))
    _WatchedDirectory.stop_watching_file(file_path)

